feat(cli): objectstack dev --cert/--key terminates TLS in the dev process, and the canonical origin follows the listener - #17725
Conversation
…and derive the canonical origin from the listener `objectstack dev --cert <path> --key <path>` terminates TLS in the dev process itself, and every origin the boot advertises follows it: the two `/.well-known/*` documents, the CSRF allow-list, the ready banner's `API:` row, the `MCP server` connect hint and the runtime state file. The developer brings the certificate. Nothing here generates one, and nothing here says anything about installing one into a trust store. - `utils/dev-tls-contract.ts` — the one reader of the flag pair, shared by `dev` and the `serve` child it spawns, so the protocol the parent derives and the protocol the child binds come from one answer. Half a pair is refused by the door the operator typed at; an unreadable file is refused by the process that would have bound the socket, and never degraded to a plain-http listener. - `resolveAuthBaseUrl(port, boundProtocol)` — only the built-in default tail follows the listener. `OS_AUTH_URL` and the rest of the configured chain keep winning: they name where a deployment is reached, not what this process bound. The parameter defaults to `http`, so a tree with no TLS flags in play resolves byte-for-byte as before. - `publishBoundPort(..., boundProtocol)` — the socket's own address, which both the runtime state file and the `objectstack:listening` IPC message send a consumer to. - `HonoPluginOptions.tls` / `HonoHttpServer` — `@hono/node-server` takes a listener factory as an option, so the TLS arm is the same fetch handler and the same drain with one different server factory. Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c Co-authored-by: Claude <noreply@anthropic.com>
…flag ablation - `adapter-tls-listener.test.ts` drives a real ephemeral TLS socket with a certificate the test mints into a tempdir, and drives the two legs against each other: the https listener refuses a plain-http request and the plain listener refuses a TLS one, so neither leg can pass by being broken. - `dev-tls-contract.test.ts` pins the three answers of the flag pair, the refusals, and — negatively — that no flag description and no line of the module mentions generating a certificate or trusting one, with an anti-vacuity case proving the same scan reads words that are there. - `dev-mcp-connect-hint-origin.test.ts` gains the https acceptance (banner row and all three hint lines, `OS_AUTH_URL` unset) and an ablation leg: the boot that passes no protocol at all and the boot that passes `http` must be byte-identical, and both must differ from the https leg. - `serve-auth-base-url-diagnostic.test.ts` pins that the listener protocol reaches the built-in tail and stops there — every configured value keeps winning, an `http://` one included. - `serve-bound-port-publication.test.ts` gains the same ablation for the state file and the IPC message, and its two source pins are updated to the spellings this change moved. Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c Co-authored-by: Claude <noreply@anthropic.com>
`check:nul-bytes` caught a raw 0x1b that a scripted edit materialised out of the escape sequence while the test was asserting ABOUT that byte — the exact slip the gate's header says every occurrence in this repo came from. Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 32 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 7 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 24 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin b3111c49433e1a4f915503627cefe3410388b35c && git checkout b3111c49433e1a4f915503627cefe3410388b35c
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 6fa2a8ae1d546170cf8a799d42e8a5fc7caac843 03ba311254f02dd44567619d25b16d2640f0ccde && git checkout -B drift-repro 6fa2a8ae1d546170cf8a799d42e8a5fc7caac843 && git merge --no-ff 03ba311254f02dd44567619d25b16d2640f0ccde
node scripts/docs-audit/affected-docs.mjs --json 6fa2a8ae1d546170cf8a799d42e8a5fc7caac843
|
…AUTH_URL default row Two hand-written pages, both found by a manual pass rather than by the docs-drift list — which is structurally blind here: a page that states a rule by its INPUTS shares no identifier with the emitter that implements it. - `environment-variables.mdx`'s `OS_AUTH_URL` row gave its default as `http://localhost:<OS_PORT>` unconditionally. This change makes that conditionally false, so it ships with the correction, and the row now also says the variable wins whatever the listener speaks. - `cli.mdx`'s `os dev` flag table enumerates every flag, so two new public flags absent from it would advertise a smaller CLI than ships. Added, plus a short section on what the pair is for. ⛔ Zero trust-store prose on either page, and nothing about obtaining or trusting a certificate beyond saying that both are the developer's own. ⛔ `content/docs/releases/` untouched. Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c Co-authored-by: Claude <noreply@anthropic.com>
|
Contract reviewHead reviewed: Reviewed-by: Verdict: the ruled surface PASSES. ⛔ The gate is NOT cleared, for one item that sits on the maintainer's floor — see the last section.Gates
The authorized widening, item by item
The ruling's hard floor — ⛔ no CA generation, ⛔ no trust-store prose — HOLDS, and the count that looked like a violation was not oneA probe for
Dedicated trust-store probe ( Scope, fences and the transfer red line13 files. Intersection with PR #17454's six lane files and PR #17718's two: NONE. ⭐ The docs half is more than the flags: the manual inputs-vs-emitter pass corrected ⛔ The one item I will not clear: the flag pair is user-reachable on
|
Contract review — PR #17725 (card #16804)⛔ First, the seat's own fault, because it is the reason this record is late. This review was declared owed by this seat at Head reviewed:
① Derived judgments — every acceptance-set and public-surface change, named and judged1. Two new public CLI flags — 2. ⭐ Contrast worth keeping, because the same measurement gave the opposite answer ninety minutes ago: PR #17863's four new 3. 4.
⇒ the declaration 5. The ruling's two ⛔ constraints are asserted, and the assertion is proved able to fail. 「⛔ 不生成自签 CA;⛔ 不打印、不文档化任何「把 CA 装进系统信任库」的指引」. 6. The no-flag path is byte-identical, and that too is ablated rather than asserted. 7. ② semver level —
|
…d the argument rather than the argument list
`serve-port-readback.e2e.test.ts` runs only in the nightly `e2e` tier, so no
pull request can redden it. Two source-text pins in its ANTI-VACUITY describe
read the raw file, and both went red on `main` for reasons that were never
about the product:
- the print-order pin did `indexOf` over the whole of `src/utils/format.ts`.
#17892 added a docblock quoting `Press Ctrl+C to stop` 281 lines above the
`console.error` that prints it, so the raw read put the tail at offset
38075 and the `API:` row at 47343 and the assertion reported a print order
that had never changed. In code position the two are 47343 and 53551, i.e.
line 984 before line 1081, exactly as the pin claims.
- the banner pin held the byte-exact `resolveAuthBaseUrl(boundPort)` call.
#17725 added a `boundProtocol` argument. The per-PR sibling
`src/commands/serve-bound-port-publication.test.ts` was updated in that
same commit; this nightly-only copy could not be, and had no way to say so
until the next sweep.
Both pins now read through `scripts/js-comment-mask.mjs`, the tree's one
answer to code-versus-prose, which also closes the other direction: a comment
naming a pinned spelling can no longer satisfy the pin with no code behind it.
The mask carries its own anti-vacuity control (blanked in place, and blanked
something).
The banner pin now binds the ARGUMENT — the row is derived from `boundPort` —
and tolerates whatever else the call grows, which is the division of labour
the two tiers imply: the per-PR sibling keeps the byte-exact line and reddens
on the PR that moves it. That this is narrower rather than looser is proven in
the test on synthetic text: the matcher rejects `resolveAuthBaseUrl(port)` and
`resolveAuthBaseUrl(requestedPort, boundProtocol)`, and a standing negative
asserts neither requested-port spelling is in `serve.ts`.
⛔ No test renamed, skipped or deleted, and no assertion dropped: the describe
gains four assertions and loses none.
Claude-Session: https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3
Co-authored-by: Claude <noreply@anthropic.com>
…n OAuth client (objectstack-ai#17210) (objectstack-ai#17755) Fixes objectstack-ai#17210 Implements the director seat's ruling on this card (`#issuecomment-5634036848`), option **B**: **the SDK gains no method; the ledger row is untouched.** The one measured trap is made visible in prose where its reader meets it. No SDK method is added, no request or response type moves, no accept set moves, and `packages/plugins/plugin-auth/src/auth-route-ledger.ts` is not in this diff. ## Premises — falsified first, on `origin/main` at `396eae33` All four rows of the dispatch's premise table hold, plus the falsification trigger it named. | # | premise | reading | |:--|:--|:--| | 1 | `oauth.applications.register` still exists | `applications: {` at `:3997`; `register: async (req: {` at `:4043`; pin named at `:4041` | | 2 | the request type is the vendor shape | `client_name?: string` at `:4044` and `redirect_uris: string[]` at `:4045` in the request type; the same two members at `:947` / `:960` in `OAuthApplication` | | 3 | the ledger row is unchanged and still `server-only` | `auth-route-ledger.ts:241`, `disposition: 'server-only'`, note verbatim as quoted on the card | | 4 | control | `disposition` returns **70** in that ledger — the probe is live | | 5 | PR objectstack-ai#17209's removal held: no bare `name` in the typed request | **zero** hits for `^\s*name\??:` in lines `4043-4055`. Positive control, **same file, same regex**: 14 bare `name` members elsewhere (`:1062`, `:1218`, `:1267`, `:1305`, `:1741`, …) | Every `client_name` hit in `packages/client/src/index.ts` was read in context, not counted: `:947` (an `OAuthApplication` member), `:1008` (inside the `OAuthApplicationPublic` `Pick`), `:4025` / `:4027` (the existing objectstack-ai#15447 docblock), `:4044` (the request type). Premise not falsified — `premise_still_valid: true`. ## 1. The JSDoc — landed bytes Added to the `oauth.applications.register` docblock, after the `metadata` paragraph and before the `Pinned by` line: ``` * ##⚠️ A plain `name` IS honoured somewhere — not here (objectstack-ai#17210) * * There is exactly one door that reads a body member spelled `name`, and * this method does not build it: * `POST /api/v1/auth/sys-oauth-application/register`, the ObjectStack * mount behind the Console's *Setup → OAuth Applications* create form. * The same objectstack-ai#15447 round drove both doors on one real socket: that mount * answered **200** to a body spelled `name`, mapped it onto * `client_name`, and the `sys_oauth_application` row's `name` column was * set; this method's route, `/oauth2/create-client`, answered **201** * with the value **stripped** — absent from the response, absent from * `applications.get`, absent from `applications.list`, and `null` in * that same column. * * ⛔ That mount is **not** an SDK door, and it is not withheld by * oversight: it is `disposition: 'server-only'` in the auth route ledger * (`packages/plugins/plugin-auth/src/auth-route-ledger.ts`), a * session-required self-service wrapper written to serve the Console's * form — and objectstack-ai#17210 ruled that it **stays** `server-only`, so no SDK * method builds its URL. To name a client from here, post `client_name`. * * The wrapper's other Console-shaped convenience is the same asymmetry's * second half: it splits that form's newline-separated redirect-URL * **textarea** into the array the vendor schema requires. ⛔ This route * performs no such split — `redirect_uris` must arrive **pre-split**, * one entry per URL, which is what an SDK caller holds anyway. ``` Both halves the ruling asked for are stated: `name` is honoured only by the Console's server-only mount, and this route requires `redirect_uris` pre-split. ## 2. `content/docs/protocol/objectui/actions.mdx` — the same fact, that page's own idiom Not a copy-paste of the JSDoc. The page is an action-authoring reference, so the note is written to an action author, about the `target` of the `create_oauth_application` example, and it is addressed at the `:424` neighbourhood — a new `####⚠️ That target is not interchangeable with the SDK's registration door` immediately after that example's `resultDialog` explanation, inside the same **Result Dialog** section. It says why the YAML above is that short (the mount does the plain-`name` mapping and the textarea split on the action's behalf), that the ledger carries it `server-only` for exactly that reason, and what an author would have to change — rename the name param to `client_name`, pre-split the redirect URLs — if they re-pointed `target` at the provider route. The docs-drift tool cannot surface this page for this diff (it states the rule by its YAML inputs, so it shares no identifier with the SDK file), which is why it was edited by hand. ## 3. The measured evidence, restated and not overstated From the objectstack-ai#15447 round, on a real socket, driven through the real client: - `POST /api/v1/auth/sys-oauth-application/register` with a body spelled `name` answered **200**, the value came back as `client_name`, and the `sys_oauth_application` row's `name` column was set. - The same spelling posted to `/oauth2/create-client` answered **201** with the value **stripped**: absent from the response, absent from `oauth.applications.get`, absent from `oauth.applications.list`, `null` in the DB column. Nothing in this PR claims more than those two readings. In particular it does not claim the mount is reachable from the SDK, nor that any behaviour changed. ## 4. Changeset — `patch`, measured, not assumed `skip-changeset` was tested and refused. `@objectstack/client`'s `files[]` is `["dist","README.md","CHANGELOG.md"]`; after `pnpm --filter @objectstack/client build`, the new docblock prose is present in **both** `dist/index.d.ts` and `dist/index.d.mts` — it ships as editor hover to every consumer. - measurement: `grep -c "A plain \`name\` IS honoured somewhere"` → `1` in each of the two files - positive control, same files, same grep shape: `grep -c "the vendor's RECORD vocabulary"` (pre-existing docblock prose) → `1` in each - negative control: an absent marker string → `0` Published bytes move ⇒ `patch`. `.changeset/17210-oauth-register-name-trap-prose.md`. ## Gates Derived mechanically with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (no hand-written path list), then reconciled with `--ran` carrying each command's own exit code, captured before any pipe. - **82 derived families — 81 run green, 0 UNRUN, 1 NOT MEASURED.** - `pnpm lint` (whole repo, `eslint . --no-inline-config`) — **exit 0**. - `pnpm --filter @objectstack/client test` — **42 files / 506 tests passed**, including the `oauth-applications-register-request-members.test.ts` pin. - `pnpm --filter @objectstack/client typecheck` — **exit 0**, test layer included (`check:test-typecheck`: 0 files / 0 errors). - Dependency closure built first: `pnpm --filter '@objectstack/client^...' build` — exit 0. Heavy runs went through `scripts/pm/os-verify-lock.sh`; verdicts read from its `VERDICT command-exit` lines. - Control characters: `grep -naP` over both edited files, zero hits, on top of `check:nul-bytes` green. **NOT MEASURED: `pnpm check:dual-build-cjs-loads`, reason:** it exited **3** with `PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/ … ⛔ This is NOT a pass: nothing was measured`. It wants a whole-repo `pnpm build` (34 packages listed, none of them `@objectstack/client`, which is built). That is CI's `Build Core` run, and the gate reads emitted runtime JS where comments are stripped, so a docblock-only diff has no way to move it. Declared rather than run. `Clause-②: no`, re-derived from the delivered diff: three files, 33 inserted lines plus a changeset, zero deletions; no declaration, export, accept set or published symbol moves. ## Acceptance notes **`docs/qa/platform-checklist/areas/identity-auth.json` (`:1008`, `:1017`, `:1078`) — read, judged out of scope, not edited.** The dispatch left this call to me, so here is the reasoning rather than the verdict alone. All three lines were read in context. They belong to `identity-auth.oauth-app-consent-loop`, and **none of them states anything false.** `:1008` enumerates the three registration doors and already records this mount's distinguishing behaviour (*"the session-required wrapper that splits the redirect-URL textarea into redirect_uris"*); `:1017` offers two of those doors as alternatives for a step whose object is the **`client_secret`**, not the client's name; `:1078` is a `source` row naming the ledger. Nothing there claims the provider route honours a plain `name`. The item never names an application, at any step, in any acceptance clause or negative — so the trap is not reachable from it, and there is no assertion to correct. Against that, editing it has a real cost in the checklist's own machinery: a semantic edit owes a `revision` bump (`scripts/check-platform-checklist.mjs` holds `revision` equal to the last `history` entry precisely so that a changed item cannot silently re-validate old run results), which would invalidate the item's recorded runs for a clarification its steps never exercise. So: read, found accurate, left alone. It is not orphaned — the carriers that will next open it are the `checklist-author` coverage sweep and any `checklist-test` run selecting `identity-auth.oauth-app-consent-loop`. **Red lines held.** No `packages/spec` path in the diff. `auth-route-ledger.ts` untouched. No `content/docs/releases/` path. No file held by objectstack-ai#17725, objectstack-ai#17718 or objectstack-ai#17093 is reached — the diff is `packages/client/src/index.ts`, `content/docs/protocol/objectui/actions.mdx` and one new changeset. --- _Generated by [Claude Code](https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
… CODE, and bind the argument rather than the argument list (objectstack-ai#18519) Part of objectstack-ai#17633 Clause-②: no The nightly `e2e` tier was red on `main` in `packages/cli/test/serve-port-readback.e2e.test.ts`. Reproduced first, at the base this branch was cut from, with the tier switch the nightly uses. Two assertions failed, and **neither was about the product** — both source-text pins in the file's `ANTI-VACUITY` describe had decayed while the invariants they claim still hold. ## What was red, and why `vitest run --project integration test/serve-port-readback.e2e.test.ts` under `OS_TEST_TIERS=nightly`, at `8cf527f8e`: ``` AssertionError: the `API:` row is no longer printed BEFORE the banner tail — keying the read-back on the tail no longer proves the row is in the buffer: expected 47343 to be less than 38075 AssertionError: the ready banner no longer derives its API row from `resolveAuthBaseUrl(boundPort)` — if it now uses the REQUESTED port, the objectstack-ai#12525 read-back is vacuous by construction: expected '// Copyright (c) 2025 ObjectStack. Li…' to contain 'externalBaseOrigin: resolveAuthBaseUr…' Test Files 1 failed (1) Tests 2 failed | 11 passed (13) ``` Both pins read the **raw** source file, and a raw read is unsound in both directions. **1. The print-order pin fabricated.** It ran `indexOf` over the whole of `src/utils/format.ts`. `ed5a1e7f2` (objectstack-ai#17892) added a docblock quoting `Press Ctrl+C to stop` at line 800 — 281 lines **above** the `console.error` at line 1081 that prints it. So the raw read put the tail at offset 38075 and the `API:` row at 47343, and the assertion reported a print order that had never changed. Read in code position the two offsets are 47343 and 53551, i.e. line 984 before line 1081, exactly as the pin claims. **2. The banner pin held a byte-exact call that a legitimate refactor moved.** `89a652b83` (objectstack-ai#17725, `dev --cert/--key`) made the call `resolveAuthBaseUrl(boundPort, boundProtocol)`. The port argument is unchanged and still the bound one; only the argument list grew. That commit updated the **per-PR** sibling pin, `packages/cli/src/commands/serve-bound-port-publication.test.ts`, in the same diff — and could not update this copy, because this file runs only on the nightly `main` sweep and no pull request can redden it. ## The fix Both pins now read through `scripts/js-comment-mask.mjs`, this tree's one answer to code-versus-prose, which blanks comment spans in place so every offset and line number survives. That repairs the fabrication and closes the other direction at the same time: a comment naming a pinned spelling can no longer satisfy a pin with no code behind it, which is exactly the vacuum the describe exists to prevent. The mask carries its own control — blanked in place (`code.length === raw.length`) and blanked something (`code !== raw`). The banner pin now binds the **argument** rather than the argument **list**: the row is derived from `boundPort`, whatever else the call grows. That is the division of labour the two tiers imply — the per-PR sibling keeps the byte-exact line and reddens on the PR that moves it, with that PR's author reading the failure; a nightly-only copy of the same byte-exact claim can only go red a day later on a card nobody can attribute. **That this is narrower, not looser, is proven rather than asserted** — inside the test, on synthetic text, so the proof survives any future rewording of `serve.ts`: ```ts expect('externalBaseOrigin: resolveAuthBaseUrl(port).baseOrigin').not.toMatch(BANNER_FROM_BOUND_PORT); expect('externalBaseOrigin: resolveAuthBaseUrl(requestedPort, boundProtocol).baseOrigin') .not.toMatch(BANNER_FROM_BOUND_PORT); ``` …beside a standing negative asserting neither requested-port spelling is in `serve.ts` at all. ⛔ No test renamed, skipped, quarantined or deleted, and no assertion dropped. The describe gains four assertions and loses none. ## Verification Everything below was run in a dedicated worktree at `63d19926e`, with the dependency closure built first. **Red, then green, same command, both with the tier env set.** The red leg restores the base copy of the test file onto the built tree, so the only variable is this diff: | tree | result | |:---|:---| | test file at `8cf527f8e` (base) | `Tests 2 failed \| 11 passed (13)`, the two assertions quoted above | | this branch | `Tests 13 passed (13)` — both real `os serve` boots included, 17.36s | Restore proven by blob equality (`d00e065c02c1b76bf42b641abed34b094cddf4c1` back on disk) and an empty `git diff HEAD` with an empty `git status --porcelain`. **Ablation — two legs, each mutating product code on disk to prove the repaired pin still binds.** No product code is changed by this PR; these mutations were made, measured and reverted. | leg | mutation | on-disk proof | pin | |:---|:---|:---|:---| | A | `resolveAuthBaseUrl(boundPort, boundProtocol)` becomes `resolveAuthBaseUrl(port, boundProtocol)` in `serve.ts` | bound-spelling 1 to 0, requested-spelling 0 to 1; blob `803b955f` to `a2f0aef3` | RED, naming the requested port | | B | the `API:` row print in `format.ts` renamed so it no longer prints `API:` | api-row-prints 1 to 0; blob `e3dda5c8` to `9ac9622f` | RED: `printServerReady no longer prints an API: row` | Both restored: blob equal to the `HEAD` blob, `git diff HEAD` empty, `git status --porcelain` empty. **Gate families.** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` from the actual changed path, reconciled with `--ran`: **49 derived / 49 run / 0 NOT-MEASURED / 0 UNRUN**. - 47 green. - `pnpm check:dual-build-cjs-loads` exited **3** (`PREREQUISITE NOT MET`, packages with no `dist/`) on the first pass — recorded as neither pass nor fail, then re-run after a full `turbo run build` and **green**: 104 published require entry points across 67 packages load, 620 emitted CommonJS files parse. - `pnpm check:cross-package-test-inputs` exits **1**, rooted in `packages/cli/test/init-created-files-summary.e2e.test.ts` — pre-existing wherever `packages/spec/dist` is built, already filed as objectstack-ai#18353 / objectstack-ai#18348, and not a path this diff touches. It does not name this PR's file (0 occurrences in its output). **Beyond the derivation** (the standing blind spot — the derivation never names the lint family): - `pnpm lint` — repo-wide, `eslint . --no-inline-config`, **exit 0** at `63d19926e`. - `pnpm --filter @objectstack/cli typecheck` — exit 0, test layer included (`tsconfig.test.json`, ledger unchanged at 3 files / 28 errors / 6 pinned signatures). - `pnpm --filter @objectstack/cli exec vitest run --project unit` — `Test Files 211 passed (211)`, `Tests 3003 passed (3003)`. That tier carries `test/vitest-tiers-partition.test.ts`, which is what would notice if the new import had moved this file between tiers. It did not. ## Changeset `skip-changeset`, graded from what actually moves rather than from the shape of the diff. `packages/cli` declares `files: ["dist", "README.md", "CHANGELOG.md"]`; `npm pack --dry-run --json` ships **533 files, 0 of them under `test/`** and 0 matching `serve-port-readback`. The one changed path is not in any published tarball, so no released package moves. ## Acceptance notes - **This PR carries no closing keyword for objectstack-ai#17633, deliberately.** The nightly workflow files nothing, edits nothing and closes nothing on green, so closure is a later reading of a green nightly and not something a merge can assert. Closing on the repair rather than on the confirmation would throw the card away along with its `os-nightly-tiers` marker if the next sweep is red for any other reason. - The card's `priority:p1` and everything in its comment thread were graded fresh and not inherited: the thread discusses older, already-repaired defects in other files, and the body — rewritten in place by the nightly's `report` job — names only this file. - Observation, not filed: the two source pins repaired here are near-duplicates of pins in `packages/cli/src/commands/serve-bound-port-publication.test.ts`, which runs per-PR. A byte-exact source pin that lives **only** in a nightly-only tier cannot be kept honest by the PR that moves the source — that is the mechanism behind this red, and this file is unlikely to be the only place it holds. Whether such pins belong in the nightly tier at all is a wider question than this card. --- _Generated by [Claude Code](https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3)_ Co-authored-by: Claude <noreply@anthropic.com>
Fixes #16804
objectstack dev
--cert<path>--key<path> terminates TLS in the dev process itself, and every origin the boot advertises follows the listener. This is director-seat ruling5617187807, batch #111 item 1, option N (narrow) — parts (1)+(2) of the card's Ask in exactly that shape.⛔ No CA generation, and no trust-store prose — 不生成 CA、不写信任库指引
The ruling, quoted verbatim (⛔ not paraphrased — 引用中文裁决时保持原文):
So, in as many words: this PR generates no certificate and no CA, and it writes no instruction anywhere — not in code, not in
--help, not in a doc page, and not as a suggestion in this body — for installing a certificate into a system trust store. The trust store is the developer's own business. The developer brings the certificate; the feature's whole job is to use it. Option F (generated CA + trust instructions) is refused on the ruling's security-statement ground; option X (document the proxy recipe) is not delivered.That refusal is asserted, not merely promised:
dev-tls-contract.test.ts's lastdescribereads both flag descriptions and the module's own source fortrust store/keychain/certutil/add-trusted-cert/self-signed/generated CAand fornode:crypto,generateKeyPairSync,X509Certificate, and an ANTI-VACUITY case proves the same scan finds words that are there — so a future edit adding that prose cannot pass by having nothing to read.The confidence gap the ruling handed the implementing seat — MEASURED
Reading: XS.
@hono/node-server@2.1.1takes the listener factory as an option. Its exportedOptionstype is a union whose https arm is, verbatim fromnode_modules/@hono/node-server/dist/index.d.mts:So terminating TLS needs no bridging code at all — the same
fetchhandler, the same route table, the same graceful drain, one different server factory. Well under an M; no stop was warranted.Premise check — all four re-derived on this branch's base
6fa2a8ae, all four holdresolveAuthBaseUrlhas a hardcodedhttp://fallback tailpackages/cli/src/commands/serve.ts:5634declared it;:5637was?? `http://localhost:${port}`packages/cli/srccontains zero TLS codehttps.createServer·createSecureServer·node:tls·node:https→ 0 each. Positive control from the same tree:createServer→ 3 hits (commands/serve.ts:255— anet.createServer()port probe — plusutils/port-contract.ts:52andserve-port-validation.test.ts:96, both in prose),from 'node:→ 313devdeclares no--cert/--keydev.ts+serve.ts;dev's flags are atcommands/dev.ts:128(static override flags). The onlycert-shaped hits inserve.tswere the word "certainly" at:410and/.well-known/*prose at:5036printMcpConnectHintalready resolves throughresolveAuthBaseUrl; not re-implemented herepackages/plugins/plugin-hono-server/src/adapter.tslikewise had zerohttpshits, against a same-file positive control of 8 forkey— so the TLS arm is genuinely new rather than a second copy of one.What follows the listener, and what deliberately does not
resolveAuthBaseUrl(port, boundProtocol)— only the built-in default tail moves. That tail is the one link in the chain nobody configured: it is the process describing its own socket, and once TLS terminates in-process http://localhost:<port> is an address no client can reach.⛔ Every configured link keeps winning —
OS_AUTH_URL, the legacyBETTER_AUTH_URL,OS_BASE_URL— anhttp://value included. They answer a different question: where the deployment is reached, which behind a proxy or a tunnel has no relation to what this process bound. A default has no standing to overrule an operator's deliberate statement about a different hop, and a "helpful" scheme upgrade there would be a bug wearing a feature's clothes.Because the whole boot already routed through that one resolver, the three acceptance surfaces follow with no second reader: the two
/.well-known/*documents (AuthManager.getCanonicalOrigin()←AuthPlugin({ baseUrl })←resolveAuthBaseUrl), the CSRF allow-list, the ready banner'sAPI:/MCP:rows, and the🤖 MCP serverblock.publishBoundPort(..., boundProtocol)is the socket's own address rather than the canonical origin, so it is ⛔ notOS_AUTH_URL-overridable: both of its consumers open that url — the runtime state file is what an external supervisor dials, the IPC message is what theos devparent learns the server from — and a hardcodedhttp://under a TLS listener hands both a machine-readable address that answers a handshake error. This change is what makes that surface false, so it ships with it.Acceptance — driven on a real boot of
examples/app-todo, bytes pasted①
--cert/--key,OS_AUTH_URLunset — all three surfaces give httpsnode packages/cli/bin/run.js dev --fresh -p 38471 --cert CERTPATH --key KEYPATH(CERTPATH / KEYPATH are the two real file paths; spelled as words because a
bracketed placeholder does not survive a GitHub body write):
GET /.well-known/oauth-protected-resourceover that real TLS socket:{ "resource": "https://localhost:38471/api/v1/mcp", "authorization_servers": ["https://localhost:38471/api/v1/auth"], "scopes_supported": ["data:read", "data:write", "actions:execute", "offline_access"], "bearer_methods_supported": ["header"], "resource_name": "ObjectStack MCP" }GET /.well-known/oauth-authorization-server(url-valued fields):{ "issuer": "https://localhost:38471/api/v1/auth", "authorization_endpoint": "https://localhost:38471/api/v1/auth/oauth2/authorize", "token_endpoint": "https://localhost:38471/api/v1/auth/oauth2/token", "jwks_uri": "https://localhost:38471/api/v1/auth/jwks", "registration_endpoint": "https://localhost:38471/api/v1/auth/oauth2/register" }And the listener really is TLS — plain http on that same port, with its exit code captured before any pipe:
The runtime state file names the socket, not a guess:
{"pid":27532,"port":38471,"url":"https://localhost:38471",...}.② Without the flags — identical to today
Same command, flags removed, port 38472:
{ "resource": "http://localhost:38472/api/v1/mcp", "authorization_servers": ["http://localhost:38472/api/v1/auth"] }state file:
{"port":38472,"url":"http://localhost:38472",...}⭐ Pinned as an ablation, not a claim — three legs, each requiring the omitted argument and an explicit
httpto be identical while both differ fromhttps:dev-mcp-connect-hint-origin.test.ts—bootWithoutProtocolArg()reproduces this file's pre-change call expression character for character and drives it beside today's call; the whole captured boot buffer (banner + hint,console.errorandconsole.login call order) must match byte for byte, over a plain port, dev's auto-shifted port and an ephemeral one.serve-auth-base-url-diagnostic.test.ts—resolveAuthBaseUrl(port)must equalresolveAuthBaseUrl(port, 'http')and differ fromresolveAuthBaseUrl(port, 'https').serve-bound-port-publication.test.ts— the same, for the state-file payload and the IPC message.adapter-tls-listener.test.ts— the http and https listeners are constructed identically but for the fourth argument, and each refuses the other's protocol, so neither leg can pass by being broken in a convenient direction.③
OS_AUTH_URLstill winsOS_AUTH_URL=https://tunnel.example.comwith both TLS flags, port 38473:{ "resource": "https://tunnel.example.com/api/v1/mcp", "authorization_servers": ["https://tunnel.example.com/api/v1/auth"] }…while the state file still names the socket:
{"port":38473,"url":"https://localhost:38473",...}— the canonical origin and the bound address answering their own questions, as designed. The awkward direction is pinned too: anhttp://OS_AUTH_URLunder a TLS listener is not upgraded.Refusals — loud, and never degraded to plain http
CERT-PATH-PLACEHOLDER / KEY-PATH-PLACEHOLDER. The bytes the CLI actually prints are
angle-bracketed (
path to the certificateandpath to its private keyinside anglebrackets) and are pinned verbatim in
dev-tls-contract.test.ts; a bracketed span doesnot survive a GitHub body write, so it is spelled out rather than silently eaten.
⛔ There is deliberately no path from either refusal back to an http listener: a developer who typed
--certasked for TLS, and answering with the other protocol would surface first as a client-side handshake error naming neither the flag nor the file. Prefer failing to falling back.Shape of the change
packages/cli/src/utils/dev-tls-contract.tsis the one reader of the pair, shared bydevand theservechild it spawns — the same judgement asport-contract.ts, for the same reason: before it, a value typed atdevwould have been refused one process later under the name of the channel it arrived on.devforwards the paths, never the bytes, so exactly one process reads the file and exactly one owns that refusal — and the parent'sboundProtocolcomes from the sameresolveDevTlsIntentcall that built the child's argv, so the scheme the hint prints and the scheme the child binds cannot part company (pinned: exactly oneresolveDevTlsIntent(call indev.ts).HonoPluginOptions.tlstakes PEM bytes, ⛔ not paths: whoever hands the adapter TLS material is the layer that knows why it has it and can name the flag the operator typed. A transport adapter taking paths would own a second reader of the same file and would have to invent a refusal from a filename.Scope — declared growth, and two fences confirmed clear
The dispatch's declared face was
commands/dev.ts,commands/serve.tsand their tests. The delivered diff grew intopackages/plugins/plugin-hono-server(adapter.ts,hono-plugin.ts, one new test) andpackages/cli/src/utils, becauseserve()— the only place a listener is created — lives in the adapter, and putting TLS anywhere else would have meant a second listener owner (Route & surface ownership §1). Contract-first: the option belongs at the producer.Checked rather than assumed: no open PR holds any file in this diff. All 9 open PRs' file lists were read; PR #17454's cli files are
index.ts,commands/init.ts,commands/migrate/{account-issuer,apply}.ts— disjoint from every path here — and no open PR touchesplugin-hono-serverat all. ⛔ Nopackages/specedit:HonoPluginOptionsis declared locally inhono-plugin.ts, so the new option needed none (packages/specwas read only). ⛔content/docs/releases/untouched.Clause-②re-derived from the delivered diff: still yes — two new public CLI flags on two commands, a new exported module, a widened exportedresolveAuthBaseUrl/publishBoundPortsignature (both additive defaults), and a new publicHonoPluginOptions.tls.Two pins this change moved, and why they are updated rather than deleted
serve-bound-port-publication.test.tsheld three source-text pins that went red on the first run — exactly their job. Each is updated to the new spelling with its intent intact: the banner pin still asserts the origin comes fromboundPort(notport), the publish-site pin still asserts the seam is handed the resolved bound port, and the positive control for thenot.toContainnegatives still assertsportreaches the transport under its own name — now by reading the construction site rather than one formatting of it.The acceptance-2 pins CAN fail — proven by mutation, not by inspection
An ablation leg only means something if it goes red when the thing it guards moves. Driven
from the committed state, on disk, with the restore in a
trap:Mutation. Both
boundProtocol: ListenerProtocol = 'http'defaults —resolveAuthBaseUrl'sand
publishBoundPort's; there are exactly two, and the script refuses to proceed on anyother count — flipped to
'https', which is precisely the "derived protocol leaked into thedefault" regression.
⭐ The first of those five is not one of this PR's tests — it is the pre-existing #10202
pin, which has guarded that fallback since before
--certexisted. A mutation that reddens apin written years before the feature is the cleanest available evidence that acceptance 2 is
guarded by the tree and not merely by this PR's own assertions.
Discipline notes, since each is a way this measurement could have been void: the restore is
git checkout HEAD -- <absolute path>(⛔ never baregit checkout --, which restores themutation back out of the index); it is verified by
git diff HEADbeing empty and by blobequality, ⛔ never by an exit code; the
trap … EXIT INT TERMuses an absolute path resolvedfrom
git rev-parse --show-toplevel; and an empty or unresolvable blob hash is treated as aFAILURE rather than as "nothing to compare". No mutated file survives — the final tree is
clean at
03ba3112.Gates — every derived family measured, at
03ba3112The set was derived from the delivered diff, not taken from the dispatch's list:
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, re-derivedafter the docs commit (13 paths → 95 commands, 30 of them families only the two
content/docs/paths schedule). Reconciled back:95 / 95 green. Four needed a second pass, and each one is worth naming:
check:nul-bytes\u001binto a raw0x1bbyte indev-tls-contract.test.ts:209, in the very assertion that is about that byte — the exact slip the gate's header says every occurrence in this repo came from. Fixed to the escape text inc6500241; nowOK (scanned 8438 text file(s) … no raw ASCII control bytes), plus agrep -naPself-scan over every changed file returning emptycheck:dual-build-cjs-loadsPREREQUISITE NOT MET— unbuilt sibling packages, ⛔ not a pass and ⛔ not a red. Re-run after the closure builds:✓ 104 published require entry point(s) across 67 package(s) load; 620 emitted CommonJS file(s) parsecheck:i18n-coverageos lintcould not loadapp-showcase's config against an unbuiltconnector-mcp, so nothing was compared. Re-run:OK (13 config(s), 621 baselined untranslated string(s), none new)check:type-check-debt--re-measureleg OOM'd — under my ownNODE_OPTIONS=--max-old-space-size=4096, which is below the 6144 MB CI-shaped ceiling the gate pins for itself. It refused to record 0 rather than lying. Re-run at 8192:OK — 5 ledger entr(ies) re-measured in 98.2s, 55 raw tsc error(s) total, none above its recorded numberexit 3from any of those three isPREREQUISITE NOT MET— NOT MEASURED, in neither direction — so none of them was reported green until it had actually run.pnpm lint— the full union, not a narrowing. The lane adds it anddispatch-gates.mjsdoes not name it. It completed over the whole repo at the final commit03ba3112(clean tree,git status --porcelainempty):The 10 changed
.tsfiles also lint clean on their own (--format json→ 10 files, 0/0). Since the union ran, no invariance argument is owed; for the record, this repo's singleeslint.config.mjsenables no type-aware linting (zeroparserOptions.project/projectServicematches — the config says so itself at:328), so a diff here cannot move an untouched file's verdict anyway.Build / typecheck / tests, all through
scripts/pm/os-verify-lock.sh(one lock per container):pnpm --filter '@objectstack/plugin-hono-server^...' --filter '@objectstack/cli^...' buildVERDICT command-exit 0(held 453s)build+typecheckVERDICT command-exit 0plugin-hono-server—adapter-tls-listener+adapter-drain2 passedfiles, 8 passed testscli --project unit— the four pin files4 passedfiles, 102 passed testspackages/cli'sintegrationtier is declared to CI: no path in this diff is an integration-tier file, abin/entry ortest/helpers/serve-process.ts, so--project unitis what is owed locally.Acceptance notes
check:nul-bytescaught a real defect in this PR before it was pushed: a Python-driven edit materialised�into a raw0x1bbyte indev-tls-contract.test.ts. Fixed to the escape text; gate re-run green over 8438 files, plus agrep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]'self-scan over every changed file returning empty.publishBoundPort's url islocalhost-hardcoded on the host half as well as the scheme half. Under a non-loopback bind that host is already wrong today, independently of TLS; this change touches only the scheme, and the host is out of this card's scope. Carrier: whoever next changes the bind host — no PR or card currently in flight names it.AuthManager.getCanonicalOrigin()'s own fallback is'http://localhost:3000'(auth-manager.ts:6088), reached only when nobaseUrlis configured at all. Everyos serve/os devpath passes one, so this branch is unreachable from the CLI and no TLS boot can hit it. Not a defect from any door this card opens. Carrier: whoever composes anAuthPluginwithout abaseUrl.维护者速读(草稿)
改了什么。
objectstack dev --cert <证书> --key <私钥>两个新 flag:开发者自带证书,dev 进程自己终止 TLS。给了这两个 flag 之后,这次启动对外宣告的所有地址一律变成https://localhost:<端口>——两个/.well-known/*发现文档、CSRF 白名单、就绪横幅的API:/MCP:行、🤖 MCP server连接提示,以及外部监管进程会去拨的 runtime 状态文件。不给 flag 时逐字节和今天一样。为什么改。 桌面端 MCP 客户端拒绝对非 https 地址发起 OAuth 登录,所以产品页面承诺的「交互式客户端自动弹浏览器登录」在本地开发服务器上根本演示不了。以前唯一的走法是镜头外手搭一页 openssl + 反向代理再手设
OS_AUTH_URL——每次演示、每次录屏、每次排查都要重来一遍。这一笔把那页准备工作删掉。风险与代价(含回滚)。 ⛔ 不生成任何证书或 CA,⛔ 也不在任何地方(代码、
--help、文档、本 PR 正文)写「把 CA 装进系统信任库」的指引——信任库仍然是开发者自己的事,这条由测试反向断言把守,将来有人加这句话会红。dev 进程在给了 flag 时持有一份私钥,这是自带证书方案本来就有的性质,不新增。风险面很窄:不给 flag 时代码路径与今天完全相同,已由四处消融测试钉住。回滚 = revert 本 PR,无数据迁移、无配置残留、无已发布键退役。已配置的OS_AUTH_URL一律优先(连http://的值也不被「升级」),所以任何现有部署的行为不动。席位意见。 (留空,待维护者定稿)
你要做的。 这是新公开 CLI 面(
Clause-②: yes),已挂needs:contract-review。请确认两点:① 两个 flag 的名字与描述文案;② 「只有回落尾巴跟随 listener、所有已配置值一律优先」这条边界是你要的。确认后按常规合并即可。This pull request was authored by Claude Code in session
session_01TSf4DV7ziu4V5j73e46b7c; that sentence is the durable attribution, keptin prose because a PR body's footer block is not reliably preserved by the platform.
Generated by Claude Code